Skip to content

feat(sep-1932): DPoP refresh token binding - #524

Open
nbarbettini wants to merge 3 commits into
modelcontextprotocol:mainfrom
nbarbettini:feat/1932-refresh-binding
Open

nbarbettini wants to merge 3 commits into
modelcontextprotocol:mainfrom
nbarbettini:feat/1932-refresh-binding

Conversation

@nbarbettini

Copy link
Copy Markdown
Contributor

Discussed here: https://discord.com/channels/1358869848138059966/1552316422951149639

Motivation and Context

Refresh tokens are long-lived credentials that may remain on a client device for months. If one is stolen, it can be exchanged repeatedly for new access tokens. RFC 9449 protects public DPoP clients by binding issued refresh tokens to the same key used at the token endpoint.

This change adds a client check for that requirement. The test authorization server can now issue and rotate refresh tokens on an opt-in basis. When a client uses one, it must present a valid DPoP proof for the original key.

Using refresh tokens remains optional. The check is SKIPPED when the client does not use the issued refresh token, and fails only when a refresh is attempted without a valid proof or with a different key.

Refresh is exercised within the existing auth/dpop scenario. This keeps the full OAuth flow in one scenario and avoids duplicated setup or wall-clock expiry waits. The DPoP fixture explicitly registers a public client, and failed refresh observations remain sticky so a later successful retry cannot hide an earlier violation.

How Has This Been Tested?

  • Full test suite: 636 tests passed
  • Typecheck, ESLint, and Prettier passed
  • Positive test for refresh with the bound DPoP key
  • Negative test for refresh without a DPoP proof
  • Negative test for refresh with a different key
  • Test confirming that clients may ignore the optional refresh token
  • Test confirming that a later successful retry does not erase an earlier invalid refresh
  • Test confirming that unrelated authorization scenarios do not receive refresh tokens

Breaking Changes

No breaking changes... if you are already doing DPoP correctly 😉

Clients participating in the DPoP extension may receive a new FAILURE if they use a refresh token without proving possession of the key to which it is bound. Clients that do not use refresh tokens are unaffected.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

nbarbettini and others added 2 commits September 24, 2026 14:51
RFC 9449 §5 binds a public client's refresh token to the DPoP key from the code exchange. The test authorization server now issues and rotates those tokens, and auth/dpop-refresh checks the client proves the same key.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@pkg-pr-new

pkg-pr-new Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npx https://pkg.pr.new/@modelcontextprotocol/conformance@524

commit: 0d53911

@nbarbettini nbarbettini changed the title feat(sep-1932): DPoP refresh binding feat(sep-1932): DPoP refresh token binding Sep 24, 2026
@nbarbettini
nbarbettini marked this pull request as ready for review September 25, 2026 13:25
Comment on lines +478 to +487

if (!entry.jkt) {
recordRefresh({
proofPresent: !header.multiple && Boolean(header.proof),
proofValid: false,
jktMatched: false
});
await rotateAndRespond(undefined, 'Bearer');
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A fully conformant refresh gets graded FAILURE here. To be precise about which flow, since there are two that look alike:

  • Flow A — bound refresh token presented without a proof: correctly rejected below (invalid_grant, "DPoP proof required for a bound refresh token") and pinned by your unit test. No issue; this is §5's binding validation working.
  • Flow B — this branch: the client did the code exchange with no DPoP proof at all, so it holds a Bearer token and an unbound refresh token. entry.jkt is undefined because there was never a key to bind. RFC 9449 §5's MUST only attaches when "a public client ... presents a valid DPoP proof at the token endpoint" at issuance — Flow B is plain RFC 6749 end to end, and a proof-less refresh of it violates nothing.

But this branch records {proofValid: false, jktMatched: false}, so refreshProofCheck() emits FAILURE with "Refresh request did not prove possession of the DPoP key bound at the authorization-code exchange" — no key was ever bound, and the check's own description scopes itself to "a bound refresh token". The client's one real defect (no token-request proof) is already correctly failing sep-1932-client-token-request-proof; this double-reports the same defect under the wrong check with a factually wrong message. One defect, one failure, right label.

Reproduced end-to-end: Bearer code exchange → proof-less refresh → 200 → check verdict FAILURE. The shipped suite never hits it because no example client combines a proof-less exchange with exerciseRefresh.

Minimal fix — an unbound refresh just isn't evidence about the binding check, so don't record it:

Suggested change
if (!entry.jkt) {
recordRefresh({
proofPresent: !header.multiple && Boolean(header.proof),
proofValid: false,
jktMatched: false
});
await rotateAndRespond(undefined, 'Bearer');
return;
}
if (!entry.jkt) {
// Unbound refresh token (issued without a token-request proof): RFC 9449
// §5's binding MUST never attached, so a proof-less refresh is plain
// RFC 6749 and is not recorded against the binding check.
await rotateAndRespond(undefined, 'Bearer');
return;
}

Verified with this change applied: tsc --noEmit clean, full suite 655/655, and the repro now lands SKIPPED (non-scoring). The one trade-off: an unbound refresh then reports identically to "client never refreshed". If you'd rather keep the trace, the richer alternative is a bound: false field on the observation that the check maps to SKIPPED with an accurate reason — happy with either.

Generated with the aid of Claude (Fable 5).

this.authSchemeCheck(),
this.freshProofCheck()
this.freshProofCheck(),
...(this.posture === 'baseline' ? [this.refreshProofCheck()] : [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A refresh-binding violation during an auth/dpop-nonce run is invisible — no FAILURE, no SKIPPED, no check at all. This gate emits the refresh check only in the baseline posture, but both postures issue refresh tokens (issueRefreshTokens is set for both scenarios) and handleRefreshGrant fully supports the nonce flow. Reproduced: in the nonce posture, a wrong-key refresh — a real §5 violation the AS catches with invalid_grant — leaves no trace in the emitted checks. A client whose refresh defect only manifests under nonce handling (e.g. falling back to a proof-less refresh after a use_dpop_nonce challenge) escapes grading entirely. Your own doc comment at line 115 already promises the right behavior ("Emits the four baseline checks plus two more") — it's this line that doesn't deliver it:

Suggested change
...(this.posture === 'baseline' ? [this.refreshProofCheck()] : [])
this.refreshProofCheck()

One caveat: this is only safe together with the reordering suggested in createAuthServer.ts (handleRefreshGrant) — as the recording stands, a challenged-but-never-retried refresh writes an all-green observation before the nonce gate, which this change would promote to a false SUCCESS.

Generated with the aid of Claude (Fable 5).

Comment on lines +566 to +607
const matched = result.jkt === entry.jkt;
recordRefresh({
proofPresent: true,
proofValid: true,
jktMatched: matched,
...(matched
? {}
: {
error: 'DPoP proof key does not match the refresh token binding'
})
});
if (!matched && dpopMisbehavior !== 'rebind-on-refresh') {
res.status(400).json({
error: 'invalid_grant',
error_description:
'DPoP proof key does not match the refresh token binding'
});
return;
}

// RFC 9449 §8 applies to the refresh grant too. Do not record it on the
// authorization_code nonce observation: honoring a challenge here must
// not satisfy §8.
if (dpopRequireNonce) {
let proofNonce: unknown;
try {
proofNonce = jose.decodeJwt(header.proof).nonce;
} catch {
proofNonce = undefined;
}
if (proofNonce !== AS_DPOP_NONCE) {
res.status(400).set('DPoP-Nonce', AS_DPOP_NONCE).json({
error: 'use_dpop_nonce',
error_description: 'Authorization server requires a DPoP nonce'
});
return;
}
}

const bindJkt =
dpopMisbehavior === 'rebind-on-refresh' ? result.jkt : entry.jkt;
await rotateAndRespond(bindJkt, 'DPoP');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two ordering problems in this arm, fixed by one swap. First, the all-green observation is recorded before the nonce gate: a refresh that gets challenged and never retried leaves {proofValid: true, jktMatched: true} although no token was issued — an uncompleted refresh that reads as a completed one. Harmless today only because the nonce posture doesn't emit the refresh check (see the companion comment on dpop.ts line 223); the moment that's fixed, this becomes a false SUCCESS. Second, the key-binding mismatch is judged before the nonce challenge — the same sequencing we're reworking on #523's authorization_code arm: the AS should challenge a valid proof for its nonce first and judge key agreement only on the nonce-carrying retry, so one client defect doesn't produce verdicts off a pre-challenge request.

The fix is just the two blocks swapped (the mismatch block itself is byte-identical, only moved below the gate):

Suggested change
const matched = result.jkt === entry.jkt;
recordRefresh({
proofPresent: true,
proofValid: true,
jktMatched: matched,
...(matched
? {}
: {
error: 'DPoP proof key does not match the refresh token binding'
})
});
if (!matched && dpopMisbehavior !== 'rebind-on-refresh') {
res.status(400).json({
error: 'invalid_grant',
error_description:
'DPoP proof key does not match the refresh token binding'
});
return;
}
// RFC 9449 §8 applies to the refresh grant too. Do not record it on the
// authorization_code nonce observation: honoring a challenge here must
// not satisfy §8.
if (dpopRequireNonce) {
let proofNonce: unknown;
try {
proofNonce = jose.decodeJwt(header.proof).nonce;
} catch {
proofNonce = undefined;
}
if (proofNonce !== AS_DPOP_NONCE) {
res.status(400).set('DPoP-Nonce', AS_DPOP_NONCE).json({
error: 'use_dpop_nonce',
error_description: 'Authorization server requires a DPoP nonce'
});
return;
}
}
const bindJkt =
dpopMisbehavior === 'rebind-on-refresh' ? result.jkt : entry.jkt;
await rotateAndRespond(bindJkt, 'DPoP');
// RFC 9449 §8 applies to the refresh grant too. Do not record it on the
// authorization_code nonce observation: honoring a challenge here must
// not satisfy §8. The nonce gate runs before the binding match so the AS
// challenges a valid proof first and judges key agreement only on the
// nonce-carrying retry — mirroring the authorization_code arm.
if (dpopRequireNonce) {
let proofNonce: unknown;
try {
proofNonce = jose.decodeJwt(header.proof).nonce;
} catch {
proofNonce = undefined;
}
if (proofNonce !== AS_DPOP_NONCE) {
res.status(400).set('DPoP-Nonce', AS_DPOP_NONCE).json({
error: 'use_dpop_nonce',
error_description: 'Authorization server requires a DPoP nonce'
});
return;
}
}
// Record the observation only once the nonce gate has passed: a
// challenged-but-never-retried refresh must not read as a completed one.
const matched = result.jkt === entry.jkt;
recordRefresh({
proofPresent: true,
proofValid: true,
jktMatched: matched,
...(matched
? {}
: {
error: 'DPoP proof key does not match the refresh token binding'
})
});
if (!matched && dpopMisbehavior !== 'rebind-on-refresh') {
res.status(400).json({
error: 'invalid_grant',
error_description:
'DPoP proof key does not match the refresh token binding'
});
return;
}
const bindJkt =
dpopMisbehavior === 'rebind-on-refresh' ? result.jkt : entry.jkt;
await rotateAndRespond(bindJkt, 'DPoP');

Generated with the aid of Claude (Fable 5).

Comment thread src/seps/sep-1932.yaml
Comment on lines +11 to +13
- check: sep-1932-client-refresh-proof
text: 'Such a client MUST present a DPoP proof for the same key that was used to obtain the refresh token each time that refresh token is used to obtain a new access token.'
url: https://www.rfc-editor.org/rfc/rfc9449.html#section-5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new row's sibling at lines 40-41 (outside the diff, so commenting here) is now false clause by clause. The excluded AS-side refresh-binding row still says: "Not exercisable today: the shared conformance test authorization server does not issue refresh tokens or handle the refresh_token grant, so a conformant-vs-misbehaving pair cannot be built to validate the check. Deferred until the test AS gains refresh support." This PR is precisely the test AS gaining refresh support — it issues and rotates refresh tokens, handles the refresh_token grant, and even ships the conformant-vs-misbehaving pair the rationale says cannot be built (the unbound-refresh and rebind-on-refresh misbehavior modes, currently exercised by unit tests). The yaml is the SEP's coverage-traceability record: leaving the old rationale in place misreports to anyone auditing coverage that AS-side refresh binding is untestable, when what's actually true after this PR is narrower — the fixture exists, but no authorization-server-under-test scenario uses it yet.

Suggested replacement for the excluded: text at line 41:

    excluded: 'AS-side half of refresh binding: the shared test AS now issues, rotates, and validates bound refresh tokens (and has unbound-refresh / rebind-on-refresh misbehavior modes), but an authorization-server-under-test scenario exercising the requirement has not yet been built. The client-side half is covered by sep-1932-client-refresh-proof.'

Generated with the aid of Claude (Fable 5).

Comment on lines +240 to +245
/**
* `expires_in` (seconds) on token responses, and the DPoP access-token
* lifetime. Default 3600. The refresh posture sets this to 30 so a client
* must refresh during the run.
*/
accessTokenExpiresIn?: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A real client will never reach a state where it has to use its refresh token, so the new check cannot measure real clients yet — it grades only this PR's own fixtures. Tokens live 3600 seconds and a run lasts seconds: a proactive refresher's timer never fires, and a reactive refresher never sees a 401 invalid_token (the token genuinely isn't expired). Either way no refresh happens, the observation sink stays empty, and sep-1932-client-refresh-proof lands SKIPPED (non-scoring) for every client except the in-repo examples, which refresh unconditionally. The mechanism that would change this is described in this very comment — "The refresh posture sets this to 30 so a client must refresh during the run" — but no such posture exists (DpopClientPosture is 'baseline' | 'nonce') and nothing anywhere sets accessTokenExpiresIn.

To be clear, merging the refresh infrastructure first and the forcing posture second is a reasonable sequencing — the AS machinery and its unit tests are solid. The gap is now tracked as #551 (a short-expiry auth/dpop-refresh posture). The ask for this PR is just that it not ship the half-built trigger:

  1. Trim this comment's last sentence — the posture it references doesn't exist yet; it's auth/dpop: refresh-binding check is unreachable for real clients — add a short-expiry posture #551.
  2. Move the expired-token 401 gate in dpopResourceAuth.ts (lines 92-101 plus the accessTokenExpired helper) to auth/dpop: refresh-binding check is unreachable for real clients — add a short-expiry posture #551, where it becomes reachable and gets tests. It's unreachable in every current scenario and exercised by no test, and it's only half the trigger anyway — forcing real clients to refresh needs short expires_in (proactive refreshers) AND 401-on-expired (reactive ones), plus example clients that handle the invalid_token re-auth flow. It also carries a latent hazard to resolve in auth/dpop: refresh-binding check is unreachable for real clients — add a short-expiry posture #551 rather than inherit silently: the 401 returns before DPoP proof validation, so in a short-expiry run the proofs on expired-token requests would never be judged.

To be explicit about scope: none of this touches the refresh-binding enforcement or its tests — the AS-side proof validation on refresh, the check, the broken-client scenarios and all ten fixture unit tests are untouched by both asks.

Generated with the aid of Claude (Fable 5).

Comment on lines +298 to +307
return this.build('sep-1932-client-refresh-proof', status, {
errorMessage,
details: {
refreshSeen: this.refreshObs.seen,
proofPresent: this.refreshObs.proofPresent,
proofValid: this.refreshObs.proofValid,
jktMatched: this.refreshObs.jktMatched,
reason: 'Client did not use the optional refresh token'
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every verdict this check emits carries a false statement in its details. The reason field — "Client did not use the optional refresh token" — is attached unconditionally, so a SUCCESS report says the client refreshed correctly and that it never refreshed, side by side (refreshSeen: true directly above it), and a FAILURE report blames a refresh the same details claim never happened. The sentence is only true on the SKIPPED branch, which is the convention elsewhere in the suite: a reason travels with the condition it explains (untestable checks pair it with untestable: true; the sibling checks in this file put only observation fields in details).

Suggested change
return this.build('sep-1932-client-refresh-proof', status, {
errorMessage,
details: {
refreshSeen: this.refreshObs.seen,
proofPresent: this.refreshObs.proofPresent,
proofValid: this.refreshObs.proofValid,
jktMatched: this.refreshObs.jktMatched,
reason: 'Client did not use the optional refresh token'
}
});
return this.build('sep-1932-client-refresh-proof', status, {
errorMessage,
details: {
refreshSeen: this.refreshObs.seen,
proofPresent: this.refreshObs.proofPresent,
proofValid: this.refreshObs.proofValid,
jktMatched: this.refreshObs.jktMatched,
...(status === 'SKIPPED'
? { reason: 'Client did not use the optional refresh token' }
: {})
}
});

Generated with the aid of Claude (Fable 5).

response_type: 'code',
client_id: 'test',
redirect_uri: REDIRECT,
code_challenge: 'x',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These unit tests run every authorization-code exchange through a deliberately broken PKCE pair. 'x'/'x' is not a valid S256 pair ('x' is also well under RFC 7636 §4.1's 43-char verifier minimum), and this AS validates the pair — so each exchange in this file records a pkce-verifier-matches-challenge FAILURE into the locally-created checks array. The tests pass only because that array is never asserted on, which makes the fixture silently dependent on the AS observing-not-enforcing PKCE, and means the refresh behavior under test runs inside flows that are simultaneously failing an unrelated conformance check.

The fix is a real pair, used in three places. Add near the imports:

import { createHash } from 'node:crypto';

// A real S256 pair (RFC 7636 §4.1: verifier 43-128 chars), so these tests
// don't depend on the test AS tolerating an invalid PKCE exchange.
const CODE_VERIFIER = 'refresh-tests-code-verifier-0123456789-0123456789ab';
const CODE_CHALLENGE = createHash('sha256')
  .update(CODE_VERIFIER)
  .digest('base64url');

Then replace the three stub values: on this line, code_challenge: CODE_CHALLENGE; at lines 116 and 176, code_verifier: CODE_VERIFIER.

(The same change was requested on #523's independently-created copy of this file — the two versions will need unifying at merge time anyway, so whichever lands second can carry one shared constant.)

Generated with the aid of Claude (Fable 5).

Comment on lines 541 to +542
// No expectedFailureSlugs → asserts every emitted check is SUCCESS (the
// three baseline checks; no as-nonce/rs-nonce checks are emitted here).
// four baseline checks; no as-nonce/rs-nonce checks are emitted here).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This edited comment now misdescribes the test twice. The run emits four baseline checks, but this client never uses its refresh token, so the fourth (sep-1932-client-refresh-proof) is SKIPPED, not SUCCESS — and with this PR's testClient.ts change, SKIPPED checks are excluded from the default all-SUCCESS assertion entirely. So the test neither sees four SUCCESS checks nor asserts anything about the refresh check. That matters beyond wording: a reader trusting this comment could delete the dedicated refresh-success test above (the one with expectedSuccessSlugs) as redundant, silently losing the only SUCCESS assertion the refresh check has.

Suggested change
// No expectedFailureSlugs → asserts every emitted check is SUCCESS (the
// three baseline checks; no as-nonce/rs-nonce checks are emitted here).
// four baseline checks; no as-nonce/rs-nonce checks are emitted here).
// No expectedFailureSlugs → asserts no FAILUREs and every non-SKIPPED
// check is SUCCESS. Three baseline checks pass; the refresh check is
// SKIPPED (this client never uses its refresh token), and no
// as-nonce/rs-nonce checks are emitted here.

Generated with the aid of Claude (Fable 5).

Comment on lines +157 to +161
/**
* What the client presented on a `refresh_token` grant (RFC 9449 §5).
* Written for bound and unbound refreshes; a refresh never satisfies the
* authorization_code §8 nonce observation.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fields in this record combine across ALL refreshes, but the comment reads as if they describe one refresh. recordRefresh ANDs every field across every refresh the client performs and keeps the first error — so after one bad refresh, proofValid stays false no matter how many good refreshes follow. That's deliberate (it's what stops a later retry from hiding a violation, and the "keeps a failed refresh observation after a later valid retry" test pins it), but nothing in this comment or the field names says so: a reader will assume proofValid: false describes the client's most recent refresh. The other observation record above (DpopTokenRequestObservation) documents its equivalent behavior explicitly ("sticky-failure: once any exchange lacks a valid proof it stays FAILURE") — this one should too:

Suggested change
/**
* What the client presented on a `refresh_token` grant (RFC 9449 §5).
* Written for bound and unbound refreshes; a refresh never satisfies the
* authorization_code §8 nonce observation.
*/
/**
* What the client presented across `refresh_token` grants (RFC 9449 §5).
* Fields are ANDed over every refresh and the first error is kept
* (sticky-failure, like DpopTokenRequestObservation above: one bad
* refresh stays FAILURE despite later good ones). Written for bound and
* unbound refreshes; a refresh never satisfies the authorization_code §8
* nonce observation.
*/

If the unbound-refresh change suggested further down lands (stop recording unbound refreshes), "bound and unbound" here should become "bound refreshes only" in the same pass.

Generated with the aid of Claude (Fable 5).

@PieterKas PieterKas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @nbarbettini for refresh token binding support (along with DPoP binding for refresh tokens).

Below is a summary of requested changes and nits — details and suggested fixes are in the inline comments. All suggested changes were tested against the full suite before posting.

Requested changes:

  1. A conformant refresh of an unbound refresh token is graded FAILURE with a message about a key that was never bound — the client's actual defect is already failing the token-request check, so this double-reports it under the wrong rule (createAuthServer.ts 478-487, committable suggestion).
  2. The refresh check is silently not emitted in the nonce posture, so a wrong-key refresh during an auth/dpop-nonce run leaves no trace — while the scenario doc comment promises four baseline checks (dpop.ts 223, committable).
  3. Companion to 2, apply together: the all-green refresh observation is recorded before the nonce gate (a challenged-but-never-retried refresh reads as completed), and the key-binding mismatch is judged before the nonce challenge — same reordering we're doing on #523's authorization-code arm (createAuthServer.ts 566-607, committable).
  4. sep-1932.yaml's excluded AS-side refresh row still says the test AS can't issue refresh tokens — this PR makes that rationale false; replacement text in the comment (anchored on the new row at lines 11-13 since the old row is outside the diff).
  5. The new check can't measure real clients yet — tokens outlive the run, so only the always-refreshing example clients ever exercise it. That's fine as sequencing, now tracked in #551 (short-expiry posture); the ask here is just to trim the comment referencing the not-yet-existing "refresh posture" and move the unreachable expired-token gate to #551 (createAuthServer.ts 240-245).
  6. details.reason ("Client did not use the optional refresh token") is attached to every verdict including SUCCESS and FAILURE, where it's false; emit it only on SKIPPED (dpop.ts 298-307, committable).

Nits (non-blocking):
7. The unit tests run every exchange on an invalid 'x'/'x' PKCE pair, silently recording a PKCE FAILURE check each time; a real S256 pair is a few lines (createAuthServer.test.ts 87).
8. The edited comment at index.test.ts 541-542 says the default path asserts four SUCCESS checks; the fourth is SKIPPED and now non-scoring (committable).
9. DpopRefreshObservation's doc comment reads as single-refresh semantics; the fields are ANDed across all refreshes with first-error retention — worth documenting like its sibling interface does (createAuthServer.ts 157-161, committable).

One merge heads-up: #523 and this PR both create createAuthServer.test.ts with same-named helpers of different shapes, so whichever lands second needs to unify them (and could adopt one shared real PKCE pair at the same time).

Generated with the aid of Claude (Fable 5).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants